feat(relay): add delivery lifecycle events to RelayService (#55)#60
Conversation
Add MessageDelivered, MessageBounced, MessageDeferred and MessageExpired events to RelayService so callers can observe delivery outcomes, for example to persist failures to an external database. Closes #55. - Add RelayDeliveryEventArgs carrying the relay message, delivery result, error, retry count and next-retry time. - Raise the events from DeliverMessageAsync at the delivered, permanent- failure, deferred and expired points. MessageBounced fires independently of NDR generation so failures are observable even when bounce messages are disabled. - Add an optional ISmtpClient factory to the RelayService constructor (backward compatible) to make delivery deterministically testable. - Add the Zetian.Relay.Tests project with 10 tests covering every event path and register it in the solution. - Document the events in the relay README and demonstrate them in BasicRelayExample. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
🎉 Welcome, Contributor!Thank you for submitting your first pull request to this repository! We're thrilled to see your contribution. ⏳ What Happens Next?
✅ PR ChecklistBefore your PR can be merged, please ensure:
💡 Tips for Success
🔄 Need to Make Changes?If you need to update your PR based on feedback:
|
There was a problem hiding this comment.
Pull request overview
Adds delivery-lifecycle observability to Zetian.Relay by introducing RelayService events (delivered/bounced/deferred/expired) and a shared RelayDeliveryEventArgs payload, with accompanying tests and documentation/examples to help callers persist delivery outcomes externally (per #55).
Changes:
- Added
RelayServicedelivery lifecycle events and event raisers, wired intoDeliverMessageAsync. - Introduced
RelayDeliveryEventArgsand a testable SMTP client factory hook onRelayService. - Added a new
Zetian.Relay.Teststest project plus README/example updates demonstrating event subscriptions.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| Zetian.slnx | Registers the new relay test project in the solution. |
| tests/Zetian.Relay.Tests/Zetian.Relay.Tests.csproj | Adds new test project for relay delivery event behavior. |
| tests/Zetian.Relay.Tests/RelayServiceEventTests.cs | Adds tests validating event firing across success, temp/permanent failures, expiry, and handler exception swallowing. |
| src/Zetian.Relay/Zetian.Relay.csproj | Exposes internals to the new test assembly. |
| src/Zetian.Relay/Services/RelayService.cs | Implements the new events, raisers, and injectable client factory; wires event emission into delivery paths. |
| src/Zetian.Relay/README.MD | Documents delivery events and provides subscription examples. |
| src/Zetian.Relay/Models/EventArgs/RelayDeliveryEventArgs.cs | Adds the shared event args payload for delivery lifecycle events. |
| examples/Zetian.Relay.Examples/BasicRelayExample.cs | Demonstrates event subscriptions in the basic relay example. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| else | ||
| { | ||
| send.ReturnsAsync(() => sendResult!()); | ||
| } |
| relayService.MessageBounced += async (sender, e) => | ||
| { | ||
| // Persist the failure to another database | ||
| await failureStore.SaveAsync(e.QueueId, e.From?.Address, e.Error, e.RetryCount); | ||
| }; |
| // Build a message whose lifetime has already elapsed so DeliverMessageAsync | ||
| // takes the expiry branch before any send attempt. | ||
| RelayMessage message = new(CreateMessage().Object, SmartHost, RelayPriority.Normal, TimeSpan.FromMilliseconds(1)); | ||
| await Task.Delay(30); | ||
|
|
| OnMessageExpired(new RelayDeliveryEventArgs(message) { Error = "Message expired" }); | ||
| return; |
Summary
Implements delivery-lifecycle events on
RelayService(closes #55) so callers can observe delivery outcomes — for example, to persist failures/successes to an external database.Events (on
RelayService)MessageDeliveredMessageBouncedMessageDeferredMessageExpiredAll carry
RelayDeliveryEventArgs(QueueId,From,Status,SmartHost,RetryCount,Result,Error,NextRetryTime,Timestamp). Raisers are wrapped in try/catch and log handler exceptions, matching the existingSmtpServerevent convention.Changes
RelayDeliveryEventArgsRelayService: 4 events +On…raisers wired intoDeliverMessageAsyncRelayServicector: optional, backward-compatibleFunc<SmartHostConfiguration, ISmtpClient>client factory for testabilityZetian.Relay.Tests(10 tests) + registered inZetian.slnxBasicRelayExamplesubscriptionsTesting
10/10 tests pass.
BasicRelayExampleverifiedMessageDeferredfiring end-to-end against an unreachable smart host.Known limitation
MessageExpiredfires from the delivery path (DeliverMessageAsync), covering messages that expire and are re-dequeued. Messages swept purely by the backgroundCleanupExpiredMessagesAsync(which usesIRelayQueue.ClearExpiredAsync(), returning only a count) do not raise the event. Closing that gap requiresClearExpiredAsyncto return the removed messages — left as a small follow-up.🤖 Generated with Claude Code